/*
 * @Desc: 文件内容描述
 * @Date: 2025-10-28
 * @LastEditors: kxw
 * @LastEditTime: 2025-11-01
 * @FilePath: \file-upload-main\backend\controller.js
 */
// 导入所需模块
const multiparty = require('multiparty') // 处理 multipart/form-data 类型的表单数据，常用于文件上传
const path = require('path') // 处理文件路径的 Node.js 内置模块
const fse = require('fs-extra') // fs 模块的增强版本，提供更多便捷的文件操作方法

/**
 * 提取文件扩展名的工具函数
 * @param {string} filename - 文件名
 * @returns {string} 文件扩展名（包括点号，例如 ".txt"）
 *
 * 为什么这样设计？（前端面试角度）
 * - 在大文件上传场景中，保持文件原有的扩展名很重要，这关系到文件类型识别和后续处理
 * - 这是一种简单高效的提取扩展名方式，避免引入额外依赖
 */
const extractExt = (filename) => filename.slice(filename.lastIndexOf('.'), filename.length) // 从最后一个点开始截取字符串作为扩展名
/**
 * 定义上传文件存储的根目录路径
 *
 * 为什么这样设计？（前端面试角度）
 * - 统一管理上传文件存储位置，便于维护和清理
 * - 使用 path.resolve 确保获得绝对路径，避免路径相关的问题
 */
const UPLOAD_DIR = path.resolve(__dirname, '..', 'upload_dir') // 获取当前目录上级的 upload_dir 文件夹路径

/**
 * 流式传输文件切片的函数
 * @param {string} path - 源文件路径（切片文件路径）
 * @param {WriteStream} writeStream - 目标写入流（合并后的完整文件流）
 * @returns {Promise} 传输完成后 resolve 的 promise
 *
 * 为什么这样设计？（前端面试角度）
 * - 使用流(Stream)处理大文件，避免占用过多内存
 * - 传输完成后自动删除切片文件，节省磁盘空间
 * - Promise封装便于异步控制和错误处理
 */
const pipeStream = (path, writeStream) =>
	new Promise((resolve) => {
		// 创建一个读取流来读取切片文件内容
		const readStream = fse.createReadStream(path)
		// 当读取完成时，删除源切片文件并 resolve promise
		readStream.on('end', () => {
			fse.unlinkSync(path) // 删除已经处理过的切片文件，节省存储空间
			resolve()
		})
		// 将读取流连接到写入流，实现文件内容传输
		readStream.pipe(writeStream)
	})

/**
 * 合并切片文件为完整文件的核心函数
 * @param {string} filePath 文件绝对路径（合并后的完整文件路径）
 * @param {string} fileHash 文件 md5 值（用作唯一标识符）
 * @param {number} fileChunkSize 每个切片的大小
 *
 * 为什么这样设计？（前端面试角度）
 * - 实现大文件分片上传的关键步骤：将客户端上传的切片合并回完整文件
 * - 使用 hash 值作为目录名避免文件名冲突，支持多用户并发上传
 * - 排序机制确保切片按照正确顺序合并，保证文件完整性
 * - 并行处理所有切片提升合并效率，但保持顺序写入
 */
const mergeFileChunk = async (filePath, fileHash, fileChunkSize) => {
	// 构造切片文件夹路径（每个文件的所有切片都放在以其 hash 命名的文件夹中）
	const chunkDir = path.resolve(UPLOAD_DIR, fileHash)

	// 读取该文件夹下的所有切片文件名
	const chunkPaths = await fse.readdir(chunkDir)

	// 根据切片下标进行排序
	// 否则直接读取目录的获得的顺序可能会错乱
	chunkPaths.sort((a, b) => a.split('_')[1] - b.split('_')[1]) // 名字是我们自己约定的, 所以固定索引 1 就行
	console.log('排序后的切片：', chunkPaths)

	// 并行处理所有切片文件，提高合并效率
	await Promise.all(
		// 遍历所有切片文件路径
		chunkPaths.map((chunkPath, index) =>
			pipeStream(
				path.resolve(chunkDir, chunkPath), // 获取切片文件的完整路径
				// 指定位置创建可写流，将切片内容写入完整文件的指定位置
				fse.createWriteStream(filePath, {
					start: index * fileChunkSize, // 写入起始位置（切片索引 * 切片大小）
					end: (index + 1) * fileChunkSize, // 写入结束位置（下一个切片的起始位置）
				})
			)
		)
	)

	fse.rmdirSync(chunkDir) // 合并后删除保存切片的目录，释放存储空间
	console.log('合并成功：', filePath)
	console.log('切片文件已删除')
	return filePath
}

/**
 * 解析 POST 请求体数据的工具函数
 * @param {Request} req - HTTP 请求对象
 * @returns {Promise} 解析完成后返回包含请求体数据的 Promise
 *
 * 为什么这样设计？（前端面试角度）
 * - Node.js 原生不提供直接获取请求体的方法，需要手动监听 data 和 end 事件
 * - 将这个过程封装成 Promise，便于在 async/await 中使用
 * - 支持 JSON 格式的请求体解析
 */
const resolvePost = (req) =>
	new Promise((resolve) => {
		let chunk = '' // 用于累积请求体数据的变量
		// 监听 data 事件，逐步接收请求体数据
		req.on('data', (data) => {
			chunk += data
		})
		// 监听 end 事件，当所有数据接收完毕后解析并返回
		req.on('end', () => {
			resolve(JSON.parse(chunk)) // 将累积的数据解析为 JSON 对象
		})
	})

// 查询文件列表,获取当前文件夹下的文件信息
const listFile = () => {
	let fileList = []
	// 文件存在直接返回（避免重复上传）
	if (fse.pathExistsSync(UPLOAD_DIR)) {
		fileList = fse.readdir(UPLOAD_DIR)
	}
	// 确定目录下只有文件，没有子目录：
	return fileList
}

/**
 * 导出一个控制器类，包含处理文件上传相关请求的方法
 *
 * 为什么这样设计？（前端面试角度）
 * - 使用类的形式组织代码，结构清晰，便于维护和扩展
 * - 将不同的业务逻辑分离到不同方法中，符合单一职责原则
 * - 便于与其他框架集成（如 Express）
 */
module.exports = class {
	/**
	 * 合并切片接口
	 * 处理客户端发送的合并请求，将已上传的切片合并为完整文件
	 *
	 * 为什么这样设计？（前端面试角度）
	 * - 分离上传和合并两个阶段，提高系统的容错性和用户体验
	 * - 客户端可以先上传所有切片，在合适的时机（如全部上传完成）触发合并
	 * - 支持断点续传：即使部分切片上传失败，也只需重新上传失败的切片
	 */
	async handleMerge(req, res) {
		try {
			// 解析请求体中的数据
			const data = await resolvePost(req)
			// 解构获取文件的 hash、文件名和切片大小
			const { hash, filename, size, name } = data

			// 构造完整文件路径和切片存储目录路径
			// 构造合并后完整文件的存储路径
			const filePath = path.resolve(UPLOAD_DIR, `${hash}_${name}${extractExt(filename)}`)

			// 文件存在直接返回（避免重复上传）
			if (fse.pathExistsSync(filePath)) {
				return resAjax(res, {
					code: 2001,
					filePath: filePath,
					message: '文件已存在',
				})
			}
			// 调用合并函数进行切片合并
			await mergeFileChunk(filePath, hash, size)

			// 返回成功响应
			return resAjax(res, {
				code: 0,
				filePath: filePath,
				message: '恭喜你，文件上传成功',
			})
		} catch (error) {
			return resAjax(res, {
				code: 301,
				message: '服务错误! 文件合并失败',
			})
		}
	}

	/**
	 * 上传切片接口
	 * 处理客户端上传的文件切片
	 *
	 * 为什么这样设计？（前端面试角度）
	 * - 采用分片上传策略，解决大文件上传的网络不稳定问题
	 * - 支持并发上传多个切片，提高上传效率
	 * - 实现秒传功能：通过检查文件是否存在避免重复上传
	 * - 使用 multiparty 库专门处理文件上传，更加稳定可靠
	 */
	async handleFormData(req, res) {
		// 创建 multiparty 表单解析器实例
		const multipart = new multiparty.Form()

		// 解析请求中的表单数据
		multipart.parse(req, async (err, fields, files) => {
			// 如果解析出错，返回错误响应
			if (err) {
				console.error('handleFormData', err)
				res.status = 500
				res.end('process file chunk failed')
				return
			}

			// 从解析结果中获取文件切片、文件 hash 和文件名
			const [chunk] = files.chunk // 文件切片
			const [hash] = fields.hash // 文件 hash 值
			const [filename] = fields.filename // hash文件名
			const [name] = fields.name // 原始文件名

			console.log('接收到文件：', filename, name)

			// 构造完整文件路径和切片存储目录路径
			const filePath = path.resolve(UPLOAD_DIR, `${hash}_${name}${extractExt(filename)}`)
			const chunkDir = path.resolve(UPLOAD_DIR, hash)

			// 文件存在直接返回（避免重复上传）
			if (fse.pathExistsSync(filePath)) {
				return resAjax(res, {
					code: 2001,
					filePath: filePath,
					message: '文件已存在',
				})
			}

			// 切片目录不存在，创建切片目录
			if (!fse.pathExistsSync(chunkDir)) {
				await fse.mkdirs(chunkDir)
			}

			// 使用 fs-extra 的 move 方法移动并重命名切片文件
			// fs-extra 专用方法，类似 fs.rename 并且跨平台
			// fs-extra 的 rename 方法 windows 平台会有权限问题
			// https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
			console.log('----', path.resolve(chunkDir, filename))

			try {
				await fse.move(chunk.path, path.resolve(chunkDir, filename))
				console.log('切片上传成功')
			} catch (error) {
				console.log('剪贴并重命名切片出错：', error)
			}

			// 返回切片上传成功的响应
			resAjax(res, {
				code: 200,
				message: '切片上传成功',
			})
		})
	}
	async handleList(req, res) {
		try {
			let list = await listFile()
			list = list.map((v) => 'upload_dir/' + v)
			return resAjax(res, {
				code: 200,
				data: list,
				message: '操作成功!',
			})
		} catch (error) {
			console.log('handleList', error)

			return resAjax(res, {
				code: 300,
				message: '服务错误! 文件列表获取失败',
			})
		}
	}
}

/**
 * 发送 JSON 格式的响应数据
 * @param {Response} res - HTTP 响应对象
 * @param {Object} obj - 要发送的数据对象
 *
 * 为什么这样设计？（前端面试角度）
 * - 统一响应格式，便于前端处理
 * - 封装通用的响应逻辑，减少重复代码
 * - 保证响应数据是标准的 JSON 格式
 */
const resAjax = (res, obj) => {
	res.end(JSON.stringify(obj))
}

/**
 * 
 * 1. 为什么采用分片上传方案？
    面试要点：

    大文件上传稳定性：避免因网络波动导致整个文件重新上传
    提高上传效率：支持并发上传多个切片
    断点续传支持：可以只重新上传失败的切片
    用户体验优化：可以显示上传进度

    2. 为什么使用 hash 作为文件标识？
    面试要点：

    实现秒传功能：相同文件计算出相同 hash，避免重复上传
    文件唯一性保证：不同文件几乎不可能有相同 hash
    切片管理方便：以 hash 命名文件夹存储对应切片

    3. 为什么需要对切片进行排序？
    面试要点：

    文件系统不保证目录遍历顺序，需要人工排序
    确保切片按正确顺序合并，保证文件完整性
    通过约定的命名规则（如 file_0, file_1）提取索引排序

    4. 为什么使用流(Stream)处理文件？
    面试要点：

    内存效率：避免将整个大文件加载到内存中
    性能优势：边读边写，不需要等待整个文件处理完毕
    可扩展性：适合处理任意大小的文件
    
    5. 为什么分离上传和合并两个接口？
    面试要点：

    降低耦合度：上传和合并是两个独立的操作阶段
    提升容错性：即使某个切片上传失败，也不影响其他切片
    用户体验：可以在所有切片上传完成后再进行合并操作
 */
